Design - Full Text Search

October 11, 2026

Last updated on October 27, 2026

📜

Originated:

Git Publish:

Released:

October 11, 2026

#2736 (ff92fe9d)

8.0.0

 

The Search command can find text in paragraphs and in images. A search of many pages is slow, because OneMore must read each page from OneNote. This document explains how the text index makes the search fast. The index only narrows the search. The search gives the same results with or without it.

 

Related reading
Design - Page Identity explains the page key that this design uses. Design - Hashtags explains the scanner that shares its page reads with the index. TechNote - OneNote ID Stability explains why the index records the page ID.

 

1. Problem and scope

 

What is slow today

OneMore reads a page from OneNote through COM. One page read takes 35 to 90 ms. A search in all notebooks reads every page, one after the other. A user with 2,000 pages waits more than one minute for each search.

 

Goals

  1. A search that uses the index gives the same results as a search that reads every page.
  2. The index never writes to a page.
  3. The user must turn the index on. Nothing is indexed before that.
  4. Indexing does not make OneNote slow.
  5. A search never fails because of the index. If the index cannot be used, the search reads the pages.

 

Non-goals

  • Word stemming, ranking, or fuzzy matching.
  • Text of a generated table of contents.
  • Sharing the index between computers. Each computer has its own index.
  • Searching text in attachments.

 

2. Concepts

 

Term

Meaning

Paragraph

One searchable piece of a page. It is typed text, or the text that OneNote recognized in one image.

Kind

0 for typed text. 1 for text recognized in an image (OCR).

Page key

The integer that the identity catalog gives to a page. The index uses it to know a page.

Stamp

The page ID and the modified time that the page had when it was indexed.

Trigram

A group of three characters. The index stores every trigram of every paragraph.

Candidate

A paragraph that the index returns. A candidate can match, or it can be a false positive.

Chosen notebook

A notebook that the user selected for indexing. The user can also choose to index all notebooks.

 

Why trigrams

A trigram index finds a term anywhere in a paragraph, and ignores case. This is how the live search treats a term. A word index cannot find the term port in Support. The trigram index can.

 

The price is size. A trigram index is about three times the size of the text that it covers.

 

Why a stamp

OneNote can give a page a new page ID when a notebook is closed and opened again. The paragraph IDs then change too. The index records paragraph IDs, so it must index the page again. The stamp lets OneMore detect this.

 

3. Architecture

The index has a write side and a read side. The write side is a consumer of the background page pass. The read side is part of the Search dialog.

 

Architecture (Extract)

 

Architecture (Refresh)

 

 

Components

 

Component

Role

IndexConsumer

Decides which pages need indexing. Reads the paragraphs of a page and stores them. Removes data that is no longer needed.

IndexBuilder

Indexes all chosen notebooks now, when the user asks. It has no time budget.

IndexProvider

The only code that reads and writes the index tables. It loads FTS5.

PageTextExtractor

Decides which paragraphs of a page are searchable. The live search and the index both use it.

TextMatchBuilder

Converts the query of the user to a regular expression, and to an FTS5 expression.

IndexedHits

Asks the index for candidates. Tests each candidate with the regular expression.

SearchEngine

Uses the answer of the index for each page that has a current stamp. Reads all other pages.

IndexSheet

The Settings page. The user turns the index on, chooses notebooks, builds, and clears.

 

Why one shared page read

The hashtag scanner and the index both need the content of a page. The PageContentPass reads each page one time and gives it to both consumers. This halves the load on OneNote. The pass also keeps the throttle, so the index does not add its own.

 

4. Data model

 

The index lives in OneMore.db, in the same file as the identity catalog.

 

Data Model (Extract)

 

Data Model (Refresh)

 

 

Table

Purpose

index_text

One row for each paragraph. This table holds the text.

index_fts

The FTS5 index. It has external content: it points to index_text and does not keep a second copy of the text.

index_page

One row for each indexed page. It holds the stamp.

index_notebook

The choice of the user for each notebook, and when it was last indexed.

index_schema

The schema version.

 

Three triggers on index_text (insert, delete, update) keep index_fts current. Only IndexProvider writes index_text, because the triggers need FTS5.

 

5. Behavior

 

5.1 Background indexing

The background pass runs after the identity pass. For each page, the consumer applies these rules.

 

Background Indexing (Extract)

 

Background Indexing PlantUML (Refresh)

 

Notes on these rules:

  • The modified time is compared for any difference, not only for a newer time. A page that the user restores from an older version is indexed again.
  • A pass reads pages for at most 5 seconds. The consumer always reads at least one page, so the backlog always gets smaller.
  • The consumer waits at least 25 ms after each page read.
  • The first index of a large set of notebooks takes many passes. This is intended.
  • The stamp holds the modified time that the page had when the pass began. This time can only be older than the page that was read. A page that the user edits during the pass is indexed again.

 

5.2 Build now

The user can start a build from Settings.

 

  1. The build holds back the background consumer. The two do not run at the same time.
  2. The build reads all chosen pages that are not current. It has no time budget.
  3. The build reports progress after each page.
  4. The user can cancel at any time.

 

Cancel is safe. Each page is one transaction. The background consumer continues from the place where the build stopped. A build only adds data. It never removes data.

 

The Search dialog uses the index when these conditions are true:

 

  • The user turned the index on.
  • The scope is not a single page.
  • Include table of contents is off.

 

Search Path (Extract)

 

Search Path PlantUML (Refresh)

 

The index finds a superset

BuildIndexQuery converts the query to an FTS5 expression. The expression finds every paragraph that can match, and maybe more. IndexedHits then tests each paragraph with the same regular expression as the live search. This is why the results are the same.

 

Part of the query

What the index does

Term of 3 or more characters

Looks up the term as a substring.

Term with *

Looks up each piece between the wildcards. A piece that is shorter than 3 characters is left to the regular expression.

Term shorter than 3 characters

Cannot narrow. The regular expression tests it.

AND

Keeps the parts that can narrow. Ignores the others.

OR

If one alternative cannot narrow, no part narrows. Every paragraph is a candidate.

NOT

Left to the regular expression.

Regular expression mode

The index is not used.

 

When the index cannot narrow the query, every paragraph is a candidate. The search is then correct, but it is not faster than reading the index table.

 

A page is answered by the index only when it is current

A page is current when its page ID and modified time equal the values in the OneNote hierarchy. Pages that are changed, or never indexed, are read from OneNote as before. The page that the user is on is always read, because it can have edits that OneNote did not record.

 

Text in images

The index always stores the text that OneNote recognized in images, with kind = 1. The query adds kind = 0 when Search text in images is off. The user can change the option without a new index.

 

5.4 Cleanup

 

Event

Action

The identity catalog gives up on a page

The consumer deletes the rows of the page.

The user stops choosing a notebook

The consumer deletes the rows of the notebook. This runs one time for each change of choice, and one time at the start of a session.

Start of a session

The consumer deletes rows for page keys that the catalog does not know.

The user selects Clear

IndexProvider.Clear deletes all rows and runs VACUUM. The notebook choices stay.

 

The index keeps no text of a notebook that the user does not choose. That text would become old, and it would use space.

 

6. Loading FTS5

 

The System.Data.SQLite build in OneMore does not register FTS5. The file SQLite.Interop.dll contains FTS5 and exports sqlite3_fts5_init. IndexProvider loads it into each connection.

 

  1. Enable extension loading on the connection.
  2. Load the entry point from the interop file. Try the name first, then the architecture folder.
  3. Disable extension loading again.

 

IndexProvider.IsAvailable runs this one time in a memory database and creates a trigram table. When this fails, the index is off and every search reads the pages.

 

A connection that did not load FTS5 can read all other tables. It fails on any statement that touches index_fts or the triggers. For this reason, only IndexProvider writes index_text.

 

7. Failure modes and open questions

 

Known limits

 

Limit

Effect

Terms shorter than 3 characters

The index cannot narrow them. The search is correct but slower.

OCR finishes late

OneNote can finish OCR after the modified time was recorded. The text appears after the next change of the page or a build.

Paragraph IDs change on reopen

The page is indexed again. This is the stamp working as intended.

Table of contents

Not indexed. A search that includes it reads the pages.

First index

Takes many passes on a large set of notebooks.

The index is large

See the measurements below.

 

Decisions

 

Decision

Reason

Trigram tokenizer, not word tokenizer

It finds a term inside a word, as the live search does.

External content FTS5 table

The text is stored one time in index_text. The caller can run the exact test on it.

Superset, then regular expression

The results are the same as the live search. The index can be imprecise and still correct.

Same PageTextExtractor for index and live search

The two paths agree on which paragraphs exist.

Stamp compares for difference, not for newer

A restored page is detected.

Index in OneMore.db

One file, and the identity catalog is in the same file. Notebook of a page is a join.

Opt-in

A user who does not turn it on never gets the schema and never pays for the load of FTS5.

Measurements

 

These numbers are an example from one database. They are not a guarantee.

 

Item

Value

Notebooks / pages

9 / 1,990

Paragraphs

103,803 typed, 1,146 image

Text size

6.8 MB typed, 0.6 MB image

Index size (index_fts)

21.4 MB typed, 2.0 MB image (about 3.2 times the text)

index_text table

13.3 MB

OneMore.db

42.7 MB

One background pass

About 62 pages in 5.5 seconds

 

The index_text table is larger than the text. The objectID column is the likely cause. It is not measured separately.

 

Here are measurements across nine test notebooks, again for demonstration purposes only. Your data storage will certainly vary depending on the size of your notebooks, pages, and image OCR results.

 

Notebook

Pages

Text paras

Raw text

Text index

OCR paras

Raw OCR

OCR index

A

447

27,761

1.5 MB

4.9 MB

418

300.9 KB

896 KB

B

436

18,026

1.5 MB

4.5 MB

141

33.4 KB

144 KB

C

129

13,250

1.0 MB

3.1 MB

76

116.3 KB

324 KB

D

145

15,654

1,016 KB

3.0 MB

92

45.9 KB

168 KB

E

439

11,214

752 KB

2.4 MB

160

42.0 KB

176 KB

F

93

6,729

544 KB

1.7 MB

123

32.3 KB

132 KB

G

157

7,227

450 KB

1.4 MB

116

43.0 KB

168 KB

H

130

2,555

97 KB

356 KB

5

2.0 KB

36 KB

I

75

1,387

45 KB

192 KB

15

2.6 KB

36 KB

Total

 

103,803

6.8 MB

21.4 MB

1,146

618 KB

2.0 MB

 

Where that 42 MB goes:

 

Sizes are the stored data per table in the snapshot. B-tree page overhead and secondary indexes account for the rest of the file.

 

Component

Size

What it is

index_fts_data

21.6 MB

The trigram index itself

index_text

13.3 MB

Paragraph rows: text, objectID, ordinal, kind

index_fts_docsize + index_fts_idx

0.65 MB

FTS5 bookkeeping

identity_page

0.6 MB

Page identity catalog

index_page

0.3 MB

Per-page index stamps

hashtags, favorites, pinned, layouts, etc.

~0.1 MB

Everything else combined

Free pages

0.3 MB

74 pages; essentially no reclaimable slack

B-tree and secondary-index overhead

~6 MB

Not measurable directly (no dbstat); this is the remainder

 

The first six rows sum to about 36.5 MB. The remaining ~6 MB is an inference from the 42.7 MB file size, not a measured figure.

 

  • The "index" is bigger than I reported. I said the text index was about 23.4 MB, but that was only the FTS5 trigram structure. The index_text table is also part of the feature and is another 13.3 MB. All search-related storage is therefore about 36 MB, or roughly 85% of the file.
  • Hashtags, favorites, identity and pinned data are negligible.

 

Why index_text is 13.3 MB for only 7.4 MB of text

 

  • The raw text is only about 7.4 MB (6.8 MB typed plus 0.6 MB OCR).
  • The objectID column is the likely culprit. OneNote object IDs look like {1}, around 50 characters each. Over 105k paragraphs that comes to about 5 MB stored per paragraph row. I haven't measured the column's size directly.
  • The remainder is small. The ordinal and kind integers and the IDX_index_text_pageKey index add little.

 

What this means

 

  • Text is stored twice. Once in index_text and once, as trigrams, in the FTS index.
  • Trigrams are expensive. Every 3-character window becomes an entry, which is why the index runs about 3x the text.
  • The WAL is separate. It adds another ~7 MB on disk next to the main file.

 

Open questions

 

  • Is it better to resolve the objectID when a hit is shown, and not store it for each paragraph? This could save about 5 MB in the database above.
  • Does detail=column in FTS5 reduce the size and keep the results correct? Phrase queries could be affected.
  • Does FTS5 load correctly on ARM64 and in the tray process? It is not verified.

 

8. Testing strategy

 

Area

Approach

IndexProvider

Use an in-memory SQLite connection. Use the IndexProvider(SQLiteConnection) constructor.

IndexConsumer.NeedsIndexing

Test each row of the decision in section 5.1.

TextMatchBuilder.BuildIndexQuery

Test short terms, wildcards, AND, OR, NOT, and regular expression mode.

IndexedHits.Load

Compare the hits of the index with the hits of a full scan of the same pages. They must be equal.

Cleanup

Test purge, prune of unchosen notebooks, orphan sweep, and clear.

Build and cancel

Cancel in the middle. Check that the next pass continues and no page is partial.

 

 

#omwiki #omdeveloper #omdesign

 

© 2026 Steven M Cohn. All rights reserved.

Please consider a sponsorship or one-time donation to support ongoing development

 

Created with OneNote.